// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Formatting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Formatting.Rules;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;

namespace dnSpy.Roslyn.Internal.SmartIndent.CSharp {
	internal partial class CSharpIndentationService {
		protected override bool ShouldUseTokenIndenter(Indenter indenter, out SyntaxToken syntaxToken)
			=> ShouldUseSmartTokenFormatterInsteadOfIndenter(
				indenter.Rules, indenter.Root, indenter.LineToBeIndented, indenter.Options, out syntaxToken);

		protected override ISmartTokenFormatter CreateSmartTokenFormatter(
			CompilationUnitSyntax root, SourceText text, TextLine lineToBeIndented,
			IndentationOptions options, AbstractFormattingRule baseIndentationRule)
		{
			var rules = ImmutableArray.Create(baseIndentationRule).AddRange(CSharpSyntaxFormatting.Instance.GetDefaultFormattingRules());
			return new CSharpSmartTokenFormatter(options, rules, root, text);
		}

		protected override IndentationResult? GetDesiredIndentationWorker(Indenter indenter, SyntaxToken? tokenOpt,
			SyntaxTrivia? triviaOpt) => TryGetDesiredIndentation(indenter, triviaOpt) ??
										TryGetDesiredIndentation(indenter, tokenOpt);

		private static IndentationResult? TryGetDesiredIndentation(Indenter indenter, SyntaxTrivia? triviaOpt) {
			// If we have a // comment, and it's the only thing on the line, then if we hit enter, we should align to
			// that.  This helps for cases like:
			//
			//          int goo; // this comment
			//                   // continues
			//                   // onwards
			//
			// The user will have to manually indent `// continues`, but we'll respect that indentation from that point on.

			if (triviaOpt == null)
				return null;

			var trivia = triviaOpt.Value;
			if (!trivia.IsSingleOrMultiLineComment() && !trivia.IsDocComment())
				return null;

			var line = indenter.Text.Lines.GetLineFromPosition(trivia.FullSpan.Start);
			if (line.GetFirstNonWhitespacePosition() != trivia.FullSpan.Start)
				return null;

			// Previous line just contained this single line comment.  Align us with it.
			return new IndentationResult(trivia.FullSpan.Start, 0);
		}

		private static IndentationResult? TryGetDesiredIndentation(Indenter indenter, SyntaxToken? tokenOpt) {
			if (tokenOpt == null)
				return null;

			return GetIndentationBasedOnToken(indenter, tokenOpt.Value);
		}

		private static IndentationResult GetIndentationBasedOnToken(Indenter indenter, SyntaxToken token) {
			Contract.ThrowIfNull(indenter.Tree);
			Contract.ThrowIfTrue(token.IsKind(SyntaxKind.None));

			var sourceText = indenter.LineToBeIndented.Text;
			RoslynDebug.AssertNotNull(sourceText);

			// case: """$$
			//       """
			if (token.IsKind(SyntaxKind.MultiLineRawStringLiteralToken)) {
				var endLine = sourceText.Lines.GetLineFromPosition(token.Span.End);

				// Raw string may be unterminated.  So last line may just be the last line of the file, which may have
				// no contents on it.  In that case, just presume the minimum offset is 0.
				var minimumOffset = endLine.GetFirstNonWhitespaceOffset() ?? 0;

				// If possible, indent to match the indentation of the previous non-whitespace line contained in the
				// same raw string. Otherwise, indent to match the ending line of the raw string.
				var startLine = sourceText.Lines.GetLineFromPosition(token.SpanStart);
				for (var currentLineNumber = indenter.LineToBeIndented.LineNumber - 1; currentLineNumber >= startLine.LineNumber + 1; currentLineNumber--) {
					var currentLine = sourceText.Lines[currentLineNumber];
					if (currentLine.GetFirstNonWhitespaceOffset() is { } priorLineOffset) {
						if (priorLineOffset >= minimumOffset) {
							return indenter.GetIndentationOfLine(currentLine);
						}

						// The prior line is not sufficiently indented, so use the ending delimiter for the indent
						break;
					}
				}

				return indenter.GetIndentationOfLine(endLine);
			}

			// case 1: $"""$$
			//          """
			// case 2: $"""
			//          text$$
			//          """
			// case 3: $"""
			//          {value}$$
			//          """
			if (token.Kind() is SyntaxKind.InterpolatedMultiLineRawStringStartToken or SyntaxKind.InterpolatedStringTextToken
			    || (token.IsKind(SyntaxKind.CloseBraceToken) && token.Parent.IsKind(SyntaxKind.Interpolation))) {
				var interpolatedExpression = token.GetAncestor<InterpolatedStringExpressionSyntax>();
				Contract.ThrowIfNull(interpolatedExpression);
				if (interpolatedExpression.StringStartToken.IsKind(SyntaxKind.InterpolatedMultiLineRawStringStartToken)) {
					var endLine = sourceText.Lines.GetLineFromPosition(interpolatedExpression.StringEndToken.Span.End);

					// Raw string may be unterminated.  So last line may just be the last line of the file, which may have
					// no contents on it.  In that case, just presume the minimum offset is 0.
					var minimumOffset = endLine.GetFirstNonWhitespaceOffset() ?? 0;

					// If possible, indent to match the indentation of the previous non-whitespace line contained in the
					// same raw string. Otherwise, indent to match the ending line of the raw string.
					var startLine = sourceText.Lines.GetLineFromPosition(interpolatedExpression.StringStartToken.SpanStart);
					for (var currentLineNumber = indenter.LineToBeIndented.LineNumber - 1; currentLineNumber >= startLine.LineNumber + 1; currentLineNumber--) {
						var currentLine = sourceText.Lines[currentLineNumber];
						if (!indenter.Root.FindToken(currentLine.Start, findInsideTrivia: true).IsKind(SyntaxKind.InterpolatedStringTextToken)) {
							// Avoid trying to indent to match the content of an interpolation. Example:
							//
							// _ = $"""
							//     {
							//  0}         <-- the start of this line is not part of the text content
							//     """
							//
							continue;
						}

						if (currentLine.GetFirstNonWhitespaceOffset() is { } priorLineOffset) {
							if (priorLineOffset >= minimumOffset) {
								return indenter.GetIndentationOfLine(currentLine);
							}

							// The prior line is not sufficiently indented, so use the ending delimiter for the indent
							break;
						}
					}

					return indenter.GetIndentationOfLine(endLine);
				}
			}

			// special cases
			// case 1: token belongs to verbatim token literal
			// case 2: $@"$${0}"
			// case 3: $@"Comment$$ in-between{0}"
			// case 4: $@"{0}$$"
			if (token.IsVerbatimStringLiteral() ||
				token.IsKind(SyntaxKind.InterpolatedVerbatimStringStartToken) ||
				token.IsKind(SyntaxKind.InterpolatedStringTextToken) ||
				(token.IsKind(SyntaxKind.CloseBraceToken) && token.Parent.IsKind(SyntaxKind.Interpolation))) {
				return indenter.IndentFromStartOfLine(0);
			}

			// if previous statement belong to labeled statement, don't follow label's indentation
			// but its previous one.
			if (token.Parent is LabeledStatementSyntax || token.IsLastTokenInLabelStatement()) {
				token = token.GetAncestor<LabeledStatementSyntax>()!.GetFirstToken(includeZeroWidth: true)
							 .GetPreviousToken(includeZeroWidth: true);
			}

			var position = indenter.GetCurrentPositionNotBelongToEndOfFileToken(indenter.LineToBeIndented.Start);

			// first check operation service to see whether we can determine indentation from it
			var indentation =
				indenter.Finder.FromIndentBlockOperations(indenter.Tree, token, position, indenter.CancellationToken);
			if (indentation.HasValue) {
				return indenter.IndentFromStartOfLine(indentation.Value);
			}

			var alignmentTokenIndentation = indenter.Finder.FromAlignTokensOperations(indenter.Tree, token);
			if (alignmentTokenIndentation.HasValue) {
				return indenter.IndentFromStartOfLine(alignmentTokenIndentation.Value);
			}

			// if we couldn't determine indentation from the service, use heuristic to find indentation.

			// If this is the last token of an embedded statement, walk up to the top-most parenting embedded
			// statement owner and use its indentation.
			//
			// cases:
			//   if (true)
			//     if (false)
			//       Goo();
			//
			//   if (true)
			//     { }

			if (token.IsSemicolonOfEmbeddedStatement() ||
				token.IsCloseBraceOfEmbeddedBlock()) {
				Debug.Assert(
					token.Parent != null &&
					(token.Parent.Parent is StatementSyntax || token.Parent.Parent is ElseClauseSyntax));

				var embeddedStatementOwner = token.Parent.Parent;
				while (embeddedStatementOwner.IsEmbeddedStatement()) {
					RoslynDebug.AssertNotNull(embeddedStatementOwner.Parent);
					embeddedStatementOwner = embeddedStatementOwner.Parent;
				}

				return indenter.GetIndentationOfLine(
					sourceText.Lines.GetLineFromPosition(embeddedStatementOwner.GetFirstToken(includeZeroWidth: true).SpanStart));
			}

			switch (token.Kind()) {
			case SyntaxKind.SemicolonToken: {
				// special cases
				if (token.IsSemicolonInForStatement()) {
					return GetDefaultIndentationFromToken(indenter, token);
				}

				return indenter.IndentFromStartOfLine(
					indenter.Finder.GetIndentationOfCurrentPosition(indenter.Tree, token, position, indenter.CancellationToken));
			}

			case SyntaxKind.CloseBraceToken: {
				if (token.Parent.IsKind(SyntaxKind.AccessorList) &&
					token.Parent.Parent.IsKind(SyntaxKind.PropertyDeclaration)) {
					if (token.GetNextToken().IsEqualsTokenInAutoPropertyInitializers()) {
						return GetDefaultIndentationFromToken(indenter, token);
					}
				}

				return indenter.IndentFromStartOfLine(
					indenter.Finder.GetIndentationOfCurrentPosition(indenter.Tree, token, position, indenter.CancellationToken));
			}

			case SyntaxKind.OpenBraceToken: {
				return indenter.IndentFromStartOfLine(
					indenter.Finder.GetIndentationOfCurrentPosition(indenter.Tree, token, position, indenter.CancellationToken));
			}

			case SyntaxKind.ColonToken: {
				var nonTerminalNode = token.Parent;
				Contract.ThrowIfNull(nonTerminalNode, @"Malformed code or bug in parser???");

				if (nonTerminalNode is SwitchLabelSyntax) {
					return indenter.GetIndentationOfLine(sourceText.Lines.GetLineFromPosition(nonTerminalNode.GetFirstToken(includeZeroWidth: true).SpanStart), indenter.Options.FormattingOptions.IndentationSize);
				}

				goto default;
			}

			case SyntaxKind.CloseBracketToken: {
				var nonTerminalNode = token.Parent;
				Contract.ThrowIfNull(nonTerminalNode, @"Malformed code or bug in parser???");

				// if this is closing an attribute, we shouldn't indent.
				if (nonTerminalNode is AttributeListSyntax) {
					return indenter.GetIndentationOfLine(
						sourceText.Lines.GetLineFromPosition(nonTerminalNode.GetFirstToken(includeZeroWidth: true).SpanStart));
				}

				goto default;
			}

			case SyntaxKind.XmlTextLiteralToken: {
				return indenter.GetIndentationOfLine(sourceText.Lines.GetLineFromPosition(token.SpanStart));
			}

			case SyntaxKind.CommaToken: {
				return GetIndentationFromCommaSeparatedList(indenter, token);
			}

			case SyntaxKind.CloseParenToken: {
				if (token.Parent.IsKind(SyntaxKind.ArgumentList)) {
					return GetDefaultIndentationFromToken(indenter, token.Parent.GetFirstToken(includeZeroWidth: true));
				}

				goto default;
			}

			default: {
				return GetDefaultIndentationFromToken(indenter, token);
			}
			}
		}

		private static IndentationResult GetIndentationFromCommaSeparatedList(Indenter indenter, SyntaxToken token) =>
			token.Parent switch {
				BaseArgumentListSyntax argument => GetIndentationFromCommaSeparatedList(indenter, argument.Arguments, token),
				BaseParameterListSyntax parameter => GetIndentationFromCommaSeparatedList(indenter, parameter.Parameters, token),
				TypeArgumentListSyntax typeArgument => GetIndentationFromCommaSeparatedList(indenter, typeArgument.Arguments,
					token),
				TypeParameterListSyntax typeParameter => GetIndentationFromCommaSeparatedList(indenter, typeParameter.Parameters,
					token),
				EnumDeclarationSyntax enumDeclaration => GetIndentationFromCommaSeparatedList(indenter, enumDeclaration.Members,
					token),
				InitializerExpressionSyntax initializerSyntax => GetIndentationFromCommaSeparatedList(indenter,
					initializerSyntax.Expressions, token),
				_ => GetDefaultIndentationFromToken(indenter, token),
			};

		private static IndentationResult GetIndentationFromCommaSeparatedList<T>(Indenter indenter, SeparatedSyntaxList<T> list,
			SyntaxToken token) where T : SyntaxNode {
			var index = list.GetWithSeparators().IndexOf(token);
			if (index < 0) {
				return GetDefaultIndentationFromToken(indenter, token);
			}

			// find node that starts at the beginning of a line
			var sourceText = indenter.LineToBeIndented.Text;
			RoslynDebug.AssertNotNull(sourceText);
			for (var i = (index - 1) / 2; i >= 0; i--) {
				var node = list[i];
				var firstToken = node.GetFirstToken(includeZeroWidth: true);

				if (firstToken.IsFirstTokenOnLine(sourceText)) {
					return indenter.GetIndentationOfLine(sourceText.Lines.GetLineFromPosition(firstToken.SpanStart));
				}
			}

			// smart indenter has a special indent block rule for comma separated list, so don't
			// need to add default additional space for multiline expressions
			return GetDefaultIndentationFromTokenLine(indenter, token, additionalSpace: 0);
		}

		private static IndentationResult GetDefaultIndentationFromToken(Indenter indenter, SyntaxToken token) {
			if (IsPartOfQueryExpression(token)) {
				return GetIndentationForQueryExpression(indenter, token);
			}

			return GetDefaultIndentationFromTokenLine(indenter, token);
		}

		private static IndentationResult GetIndentationForQueryExpression(Indenter indenter, SyntaxToken token) {
			// find containing non terminal node
			var queryExpressionClause = GetQueryExpressionClause(token);
			if (queryExpressionClause == null) {
				return GetDefaultIndentationFromTokenLine(indenter, token);
			}

			// find line where first token of the node is
			var sourceText = indenter.LineToBeIndented.Text;
			RoslynDebug.AssertNotNull(sourceText);
			var firstToken = queryExpressionClause.GetFirstToken(includeZeroWidth: true);
			var firstTokenLine = sourceText.Lines.GetLineFromPosition(firstToken.SpanStart);

			// find line where given token is
			var givenTokenLine = sourceText.Lines.GetLineFromPosition(token.SpanStart);

			if (firstTokenLine.LineNumber != givenTokenLine.LineNumber) {
				// do default behavior
				return GetDefaultIndentationFromTokenLine(indenter, token);
			}

			// okay, we are right under the query expression.
			// align caret to query expression
			if (firstToken.IsFirstTokenOnLine(sourceText)) {
				return indenter.GetIndentationOfToken(firstToken);
			}

			// find query body that has a token that is a first token on the line
			if (queryExpressionClause.Parent is not QueryBodySyntax queryBody) {
				return indenter.GetIndentationOfToken(firstToken);
			}

			// find preceding clause that starts on its own.
			var clauses = queryBody.Clauses;
			for (var i = clauses.Count - 1; i >= 0; i--) {
				var clause = clauses[i];
				if (firstToken.SpanStart <= clause.SpanStart) {
					continue;
				}

				var clauseToken = clause.GetFirstToken(includeZeroWidth: true);
				if (clauseToken.IsFirstTokenOnLine(sourceText)) {
					return indenter.GetIndentationOfToken(clauseToken);
				}
			}

			// no query clause start a line. use the first token of the query expression
			RoslynDebug.AssertNotNull(queryBody.Parent);
			return indenter.GetIndentationOfToken(queryBody.Parent.GetFirstToken(includeZeroWidth: true));
		}

		private static SyntaxNode GetQueryExpressionClause(SyntaxToken token) {
			var clause = token.GetAncestors<SyntaxNode>()
							  .FirstOrDefault(n => n is QueryClauseSyntax or SelectOrGroupClauseSyntax);

			if (clause != null) {
				return clause;
			}

			// If this is a query continuation, use the last clause of its parenting query.
			var body = token.GetAncestor<QueryBodySyntax>();
			if (body != null) {
				if (body.SelectOrGroup.IsMissing) {
					return body.Clauses.LastOrDefault();
				}
				else {
					return body.SelectOrGroup;
				}
			}

			return null;
		}

		private static bool IsPartOfQueryExpression(SyntaxToken token) {
			var queryExpression = token.GetAncestor<QueryExpressionSyntax>();
			return queryExpression != null;
		}

		private static IndentationResult GetDefaultIndentationFromTokenLine(Indenter indenter, SyntaxToken token,
			int? additionalSpace = null) {
			var spaceToAdd = additionalSpace ?? indenter.Options.FormattingOptions.IndentationSize;

			var sourceText = indenter.LineToBeIndented.Text;
			RoslynDebug.AssertNotNull(sourceText);

			// find line where given token is
			var givenTokenLine = sourceText.Lines.GetLineFromPosition(token.SpanStart);

			// find right position
			var position = indenter.GetCurrentPositionNotBelongToEndOfFileToken(indenter.LineToBeIndented.Start);

			// find containing non expression node
			var nonExpressionNode = token.GetAncestors<SyntaxNode>().FirstOrDefault(n => n is StatementSyntax);
			if (nonExpressionNode == null) {
				// well, I can't find any non expression node. use default behavior
				return indenter.IndentFromStartOfLine(indenter.Finder.GetIndentationOfCurrentPosition(indenter.Tree, token,
					position, spaceToAdd, indenter.CancellationToken));
			}

			// find line where first token of the node is
			var firstTokenLine =
				sourceText.Lines.GetLineFromPosition(nonExpressionNode.GetFirstToken(includeZeroWidth: true).SpanStart);

			// single line expression
			if (firstTokenLine.LineNumber == givenTokenLine.LineNumber) {
				return indenter.IndentFromStartOfLine(indenter.Finder.GetIndentationOfCurrentPosition(indenter.Tree, token,
					position, spaceToAdd, indenter.CancellationToken));
			}

			// okay, looks like containing node is written over multiple lines, in that case, give same indentation as given token
			return indenter.GetIndentationOfLine(givenTokenLine);
		}
	}
}
